本文同步刊載於 「為你自己學 Python - CPython 專案簡介」
這個章節會介紹 CPython 專案的結構,以及如譯編譯專案,我們會試著從原始碼來編譯出自己魔改過的 Python,來感受一下當 Python Core Dev 是什麼感覺(開玩笑的)!
首先,要開始讀原始碼,得先把專案從 GitHub 上拉一份下來:
$ git clone git@github.com:python/cpython.git
扣掉一些比較不重要的檔案,剛從 GitHub 拉下來的 CPython 專案的目錄大概是這樣:
CPython
├── Doc
├── Grammar
├── Include
├── Lib
├── Mac
├── Misc
├── Modules
├── Objects
├── PC
├── PCbuild
├── Parser
├── Programs
├── Python
└── Tools
先簡單介紹一下每個目錄裡放的檔案:
Doc
:這很好猜,就如同它的名字一樣,就是放文件的地方,是用 reStructuredText(.rst
) 格式編寫的,如果晚上睡不著可以拿出來啃,助眠效果滿點!Grammar
:定義 Python 語法規則解析用的文件。Include
:專案裡 C 語言用到的 Header 檔案,後續如果想要幫 CPython 寫 extension 的話,應該都會用到這裡的檔案。Lib
:標準函式庫,這目錄裡的東西是用 Python 寫的,如果略懂 Python 的話,這個目錄裡的東西應該讀起來會比較親切。Modules
:同 Lib
目錄,不過這裡的內容是用 C 語言寫的。Mac
:這是給 Mac 作業系統用的東西。Misc
:雜七雜八的檔案,依我自己個人的習慣,我開這種目錄就是用來放那種不知道怎麼分類的東西。Objects
:所有 Python 內建物件的原始碼在這裡,例如 str
或是 list
都在這裡。PCbuild
:這是給 Windows 作業系統用的東西,特別是 Visual Studio,裡面有可以直接點兩下就能開啟的專案檔。PC
:同上,但是是給比較早期的 Windows 版本用的。大多數已經過時,但有些文件仍然是為了相容性而保留下來。Parser
:把 .py
檔轉換成 Python 看的懂的 Token 的程式碼在這裡,難度有一點高。Programs
:存放與 CPython 執行檔相關的原始碼。Python
:CPython 直譯器(Interpreter)的原始碼在這裡,難度比較高,但對直譯器有興趣的可以看看。Tools
:一些開發和維護 Python 的輔助工具。以這整個系列單元來說,比較常看到的應該是 Include
、Lib
、Modules
、Objects
和 Python
這幾個目錄,這些都是 CPython 直譯器的核心原始碼,如果想要了解 Python 的運作原理,可能就得多一些時間泡在這些目錄裡。
專案下載之後,先 cd
切換到目錄裡並執行 ./configure
指令:
$ ./configure
如果在 ./configure
後面加上 --prefix
參數,像這樣:
$ ./configure --prefix=/tmp/my-python
有特別加上 --prefix
的話,之後如果執行 make install
指令的時候,就會把 Python 以及相關的程式安裝到 /tmp/my-python
目錄裡。以目前來說我並沒打算執行 make install
進行安裝,所以可以先不加 --prefix
參數,等到後續有機會執行其它外部程式例如 pip
的時候再加即可。
剛才這個 ./configure
指令會在畫面上不斷的跳出一堆我看不懂的資訊,這是在檢查系統環境,看看有沒有缺什麼套件或函式庫,沒問題的話會產生一個 Makefile
檔案,這個檔案會告訴 make
指令待會應該要怎麼編譯整個專案。
接著執行 make
指令,這個指令會根據剛才產生的 Makefile
檔案來編譯整個專案:
$ make
這個過程可能會花一點點時間,如果整個編譯沒出錯的話,應該會在根目錄有個 python.exe
執行檔,即使在 macOS 上也是叫這個名字。我知道在 macOS 看到 .exe
可能有點不太習慣,不過這是刻意的設計,原因是因為在 CPython 專案裡原本就有個 Python/
目錄,所以刻意選擇 python.exe
而不是 python
避免跟這個目錄發生衝突。
如果想要「安裝」剛才我們自己編譯出來的 Python 版本,可以執行:
$ make install
如果剛才在 ./configure
指令後面有加上 --prefix
參數,那這個指令會把 Python 安裝到指定的目錄裡。不過就算不安裝也沒關係,執行剛才編譯出來的 python.exe
也可以直接執行。接下來,執行剛才編譯出來的 python.exe
,就會看到我們熟悉的 REPL 環境了:
$ ./python.exe
Python 3.12.6+ (heads/3.12:b2a7d718e3b, Sep 15 2024, 23:31:57) [Clang 15.0.0 (clang-1500.3.9.4)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> import sys
>>> sys.version
'3.12.6+ (heads/3.12:b2a7d718e3b, Sep 15 2024, 23:31:57) [Clang 15.0.0 (clang-1500.3.9.4)]'
在版號後面的 +
表示這個 Python 版本並不是一個正式發行的版本,可能是「開發版本」或我們自己拿原始碼來編譯的「自訂版本」。
我們在上個章節練習過使用 printf()
來印點東西,這回我們動手改一點 CPython 原始碼。例如,我想要的效果是一進到 REPL 的時候就先印個 Hello,離開 REPL 的時候也有禮貌的說聲 Goodbye,禮多人不怪嘛!所以我們得先找到進到 REPL 的那段程式碼。這段程式碼在 Python/pythonrun.c
裡,翻一下 _PyRun_InteractiveLoopObject()
函數,應該會看到一個 do...while...
迴圈,這迴圈就是全名 Read-Eval-Print Loop 的 REPL 的那個 Loop:
// 檔名:Python/pythonrun.c
int
_PyRun_InteractiveLoopObject(FILE *fp, PyObject *filename, PyCompilerFlags *flags)
{
PyCompilerFlags local_flags = _PyCompilerFlags_INIT;
// ... 略 ...
do {
ret = PyRun_InteractiveOneObjectEx(fp, filename, flags);
// ... 略 ...
} while (ret != E_EOF);
return err;
}
這段程式應該不難懂,重點就在迴圈裡而已。所以如果我想在進到 REPL 迴圈之前先打聲招呼,應該只要在 do
前面來個 printf()
就好。為了感覺自己有在寫點程式,我刻意在這個檔案寫一個 say_something()
函數,其實它就只是把傳進去的字串印出來而已:
// 檔名:Python/pythonrun.c
void
say_something(const char *message)
{
printf("==============\n");
printf("%s\n", message);
printf("==============\n");
}
因為待會要在 _PyRun_InteractiveLoopObject()
函數裡呼叫 say_something()
,所以要把 say_something()
寫在 _PyRun_InteractiveLoopObject()
的前面。然後就可以準備來呼叫它了:
// 檔名:Python/pythonrun.c
int
_PyRun_InteractiveLoopObject(FILE *fp, PyObject *filename, PyCompilerFlags *flags)
{
PyCompilerFlags local_flags = _PyCompilerFlags_INIT;
// ... 略 ...
say_something("Hello CPython"); // 加這行
do {
ret = PyRun_InteractiveOneObjectEx(fp, filename, flags);
// ... 略 ...
} while (ret != E_EOF);
say_something("Bye"); // 加這行
return err;
}
這樣就能在進到 REPL 的時候印出 Hello CPython
,離開 REPL 的時候印出 Bye
了。不像 Python 或 JavaScript 之類的程式語言改完立刻執行就能看到效果,C 語言得要先編譯才行。所以接著需要執行 make
指令重新編譯 CPython,不過這次不會整個專案重新編譯,所以速度上應該會比上次快一些。
編譯完之後再重新執行一次:
$ ./python.exe
Python 3.12.6+ (heads/3.12-dirty:b2a7d718e3b, Sep 16 2024, 14:46:05) [Clang 15.0.0 (clang-1500.3.9.4)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
=============
Hello CPython
=============
>>> 1 + 2
3
>>> print("你好")
你好
>>> ^D
=============
Bye
=============
一進到 REPL 就會看節 Hello CPython
,在按下 Ctrl+D 離開 REPL 的時候也會印出 Bye
字樣,這樣就算是成功了!
不過這還有個小問題,因為要離開 REPL 除了 Ctrl + D 之外還可以輸入 exit()
,但現在輸入 exit()
並不會印出 Bye
,但這還容易看出來的,你看原本的 do...while...
迴圈就只有判斷 ret != E_EOF
而已。這個問題待會我們再來處理,主要是這個 say_something()
函數之後可能在別的地方還會用到,所以我想把它抽出來,後續要用的時候就可以重複呼叫這個函數。
也趁這個機會學一下在 C 語言裡怎麼把函數整理成模組!
在 C 語言裡要定義模組,大概就是先在某個 .h
檔案宣告函數的原型,然後在另一個 .c
檔裡實作這個函數的功能,這在 C 語言是很常見的做法。根據前面對 CPython 的介紹,這種要被引入的 .h
檔案通常會放在 Include
目錄裡,而用 C 語言寫的 .c
檔案通常會放在 Modules
、Python
或 Objects
目錄裡。Modules
目錄通常用來放擴展模組(C Extension),而核心功能通常在 Python
或 Objects
目錄中。由於我們加的這個 say_something()
函數算是修改直譯器的行為,我把它擺在 Python
目錄更為合適。
我們就照著 CPython 的慣例,首先在 Include
目錄裡建立一個 greeting.h
檔案。名字你可以自己決定,檔案內容如下
// 檔案:Include/greeting.h
#ifndef _PY_GREETING_H
#define _PY_GREETING_H
extern void say_something(const char *message);
#endif
前面兩行的 #ifndef
和 #define
是 Header Guard,是一種為了避免這個檔案被重複引入從而避免編譯錯誤的小技巧,這裡的 ifndef
是「if not defined」的縮寫,而 _PY_GREETING_H
就只是我自己隨便編的名字,只要不跟其它的重複就好。接著我們在 Python
目錄裡建立一個 greeting.c
檔案,檔案內容如下:
// 檔案:Python/greeting.c
#include <stdio.h>
#include "greeting.h"
void say_something(const char *message)
{
printf("=============\n");
printf("%s\n", message); // 使用傳入的 message
printf("=============\n");
}
內容跟剛才寫沒什麼差別,只是多引入了 greeting.h
檔案,這樣編譯器才知道 say_something()
函數的原型。
接著我們要告訴 CPython 要把這兩個檔案編譯進去,這樣才能在 CPython 的原始碼裡使用 say_something()
函數。在 CPython 專案裡,要編譯的檔案都會在 Makefile.pre.in
裡列出來,這個檔案是用來產生 Makefile
的模板,所以我們要在這裡加上 greeting.c
這個檔案,搜尋一下 PYTHON_OBJS
,把我們自己寫的 greeting
找個地方加上去:
// 檔案:Makefile.pre.in
PYTHON_OBJS= \
Python/_warnings.o \
... 略 ...
Python/suggestions.o \
Python/perf_trampoline.o \
Python/greeting.o \ <-- 加上這行
Python/$(DYNLOADFILE) \
$(LIBOBJS) \
$(MACHDEP_OBJS) \
$(DTRACE_OBJS) \
@PLATFORM_OBJS@
存檔之後需要重新執行 ./configure
指令,請它再次幫我們產生 Makefile
。接著再次執行 make
指令重新編譯 CPython,這樣就會把 greeting.c
編譯成 greeting.o
。
這樣一來,我們就可以我們想要的地方,例如 Python/pythonrun.c
裡呼叫 say_something()
函數了:
// 檔案:Python/pythonrun.c
#include "pycore_pylifecycle.h" // _Py_UnhandledKeyboardInterrupt
#include "pycore_pystate.h" // _PyInterpreterState_GET()
#include "pycore_sysmodule.h" // _PySys_Audit()
#include "pycore_traceback.h" // _PyTraceBack_Print_Indented()
#include "greeting.h" // <-- 加上這個
// ... 略 ...
這樣以後想要用 say_something()
函數的時候,只要引入 greeting.h
就可以了。
剛才在 REPL 裡輸入 exit()
不會觸發 say_something()
的問題,這是因為 exit()
會直接結束程式,不會進到迴圈裡面。所以我們要在 Python/pythonrun.c
裡找到 handle_system_exit()
函數,這個函數是用來處理離開 REPL 的,我們可以在裡面加上一行 say_something("Bye");
:
// 檔案:Python/pythonrun.c
static void
handle_system_exit(void)
{
int exitcode;
if (_Py_HandleSystemExit(&exitcode)) {
say_something("Bye"); // <-- 加這行
Py_Exit(exitcode);
}
}
重新再 make
一次,應該就行了:
$ ./python.exe
Python 3.12.6+ (heads/3.12-dirty:b2a7d718e3b, Sep 16 2024, 15:41:49) [Clang 15.0.0 (clang-1500.3.9.4)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
=============
Hello CPython
=============
>>> print("Hey")
Hey
>>> exit()
=============
Bye
=============
Good,打完收工!
大家在學 Python 的時候,可能聽過「在 Python 裡什麼東西都是『物件』」的說法,所以下個章節我們就先從這所謂的「物件」開始看吧,看看它到底是什麼東西。
本文同步刊載於 「為你自己學 Python - CPython 專案簡介」